Skip to content

feat: Add parental controls and global content exclusions#3127

Open
MagicalPotato0001 wants to merge 2 commits into
seerr-team:developfrom
MagicalPotato0001:parental-controls
Open

feat: Add parental controls and global content exclusions#3127
MagicalPotato0001 wants to merge 2 commits into
seerr-team:developfrom
MagicalPotato0001:parental-controls

Conversation

@MagicalPotato0001
Copy link
Copy Markdown

@MagicalPotato0001 MagicalPotato0001 commented Jun 6, 2026

Title

Add parental controls and global content exclusions

Description

Adds per-user parental controls and app-wide content exclusion settings.

This change adds parental control settings to user profiles, allowing admins to set maximum movie/series ratings and optionally block unrated content.

It also adds global Seerr settings for excluding specific ratings and TMDB keyword tags from discover/movie/series browsing independently from the blocklist. This allows admins to allow ratings like R while still hiding titles with specific tags. Manual search remains unaffected by the global exclusions.

AI Assistance Disclosure

This pull request was developed with assistance from AI tooling(codex). The changes were reviewed and tested locally before submission. I have experience as a react/nextjs developer, I was planning on making it mostly myself but decided to try out codex since this feature was very needed for me.

How Has This Been Tested?

Tested locally on Windows with Node/pnpm project dependencies installed.

Ran:

  • pnpm typecheck:server
  • pnpm typecheck:client
  • pnpm build
  • Prettier checks on touched files

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract (not sure about this)
  • Database migration (if required)

Summary by CodeRabbit

  • New Features

    • Parental controls: enable/disable, region-specific certification limits, and option to block unrated content.
    • Global exclusions: filter content by certification and by tag/keyword.
    • Parental/global filters applied across search, discovery, trending, recommendations, similar, watchlist, movie and TV detail endpoints.
  • UI

    • Settings and user profile screens updated to configure parental controls and global exclusions.
  • Chores

    • Node pinned to 22.22.3; improved discovery pagination; added image metadata label for builds.

…over pages

Add parental controls and global content exclusions
@MagicalPotato0001 MagicalPotato0001 requested a review from a team as a code owner June 6, 2026 22:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 6, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 936bb659-b307-4050-b2f2-69bdda49ac3d

📥 Commits

Reviewing files that changed from the base of the PR and between 7966253 and a66e2b0.

📒 Files selected for processing (4)
  • Dockerfile
  • package.json
  • server/api/jellyfin.ts
  • src/components/BlocklistedTagsSelector/index.tsx
✅ Files skipped from review due to trivial changes (1)
  • Dockerfile

📝 Walkthrough

Walkthrough

Adds per-user parental controls and admin global exclusions, a TMDb-backed filtering module, route enforcement (discover/search/movie/tv/watchlist/request), persistence + migrations, and UI for user and admin settings.

Changes

Parental Controls & Global Filtering System

Layer / File(s) Summary
Entity schema and database migrations
server/entity/UserSettings.ts, server/interfaces/api/userSettingsInterfaces.ts, server/migration/postgres/..., server/migration/sqlite/...
Adds five parental-control fields to UserSettings, updates API response interface, and adds PostgreSQL/SQLite migrations to persist and rollback these fields.
Core parental controls and filtering module
server/lib/parentalControls.ts
New module with user parental-control decision logic (certification bounds, unrated handling, adult blocking), plus global certification and tag/keyword exclusion filters and TMDb-detail-backed result filtering helpers.
Global settings configuration schema
server/lib/settings/index.ts
Extends MainSettings and default Settings with excluded movie/TV certifications, excluded certification region, and excluded movie/TV tags fields.
Request validation and route error handling
server/entity/MediaRequest.ts, server/routes/request.ts
Runs parental-control allowance check in MediaRequest.request and maps ParentalControlRestrictedError to 403 in the media request POST handler.
Search, movie, TV, and discover route filtering
server/routes/search.ts, server/routes/movie.ts, server/routes/tv.ts, server/routes/discover.ts
Applies parental-control and global rating/tag exclusion filtering across discovery, trending, keyword, genre-slider, watchlist, search, movie and TV detail, recommendations, and similar endpoints; detail endpoints return 403 when blocked.
User settings persistence API route
server/routes/user/usersettings.ts
Extends /main GET/POST to return and persist parentalControlsEnabled, parentalControlsRegion, maxMovieCertification, maxTvCertification, and blockUnrated.
User-facing parental controls UI
src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
Adds permission-gated parental controls section with toggle, certification selectors, region value, and block-unrated checkbox; wires Formik initialValues and POST payload.
Admin global settings UI and component enhancements
src/components/Settings/SettingsMain/index.tsx, src/components/BlocklistedTagsSelector/index.tsx
Adds excluded certification and tag selectors to SettingsMain; updates BlocklistedTagsSelector to accept fieldName and render react-select in a portal with custom styles.
Pagination fix and configuration updates
src/hooks/useDiscover.ts, package.json, next-env.d.ts, Dockerfile, server/api/jellyfin.ts
Fixes isReachingEnd heuristic to compare fetched pages to totalPages; adds wdev script and Volta Node pin; references dev Next.js types; adds OCI source label to Dockerfile; passes API timeout to Jellyfin client.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MediaRequest
  participant ParentalControls
  participant TMDb
  User->>MediaRequest: create/request media
  MediaRequest->>ParentalControls: isMediaAllowedByParentalControls(user, mediaType, media)
  ParentalControls->>TMDb: fetch media details (certifications, keywords)
  TMDb-->>ParentalControls: details (certifications, keywords)
  ParentalControls->>ParentalControls: evaluate user cert bounds, unrated, adult, global exclusions
  ParentalControls-->>MediaRequest: allow OR throw ParentalControlRestrictedError
  alt Allowed
    MediaRequest->>MediaRequest: continue persistence and request creation
  else Blocked
    MediaRequest-->>User: 403 Forbidden
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • seerr-team/seerr#2577: Related changes touching watchlist/related-media flows and mediaType disambiguation that interact with filtering pipelines.

Suggested reviewers

  • fallenbagel
  • 0xSysR3ll
  • gauthier-th

Poem

🐰 I hopped through code with careful paws,

Filters stitched without a flaw—
Ratings checked and tags held back,
Now families browse a safer track. 🎬🌱

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: adding parental controls and global content exclusions, which are the primary features implemented across the entire changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/routes/user/usersettings.ts (1)

135-152: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Enforce permission check for parental control field updates.

The parental control fields are currently accepted from req.body without verifying that the requester has MANAGE_USERS permission. This allows users to modify their own parental controls via direct API calls, even though the UI only exposes these settings to users with MANAGE_USERS permission (see UserGeneralSettings/index.tsx line 437).

Parental controls should follow the same authorization pattern as quota fields (lines 116–124): only users with MANAGE_USERS permission should be able to configure parental controls on other users, and users should not be able to modify their own parental controls.

🔒 Proposed fix to add permission guard
     user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
     user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
-    user.settings.parentalControlsEnabled = req.body.parentalControlsEnabled;
-    user.settings.parentalControlsRegion = req.body.parentalControlsRegion;
-    user.settings.maxMovieCertification = req.body.maxMovieCertification;
-    user.settings.maxTvCertification = req.body.maxTvCertification;
-    user.settings.blockUnrated = req.body.blockUnrated;
+
+    // Update parental controls only if requester has MANAGE_USERS permission
+    // and target user is not an admin (similar to quota logic at lines 116-124)
+    if (
+      req.user?.hasPermission(Permission.MANAGE_USERS) &&
+      !user.hasPermission(Permission.MANAGE_USERS) &&
+      req.user?.id !== user.id
+    ) {
+      user.settings.parentalControlsEnabled = req.body.parentalControlsEnabled;
+      user.settings.parentalControlsRegion = req.body.parentalControlsRegion;
+      user.settings.maxMovieCertification = req.body.maxMovieCertification;
+      user.settings.maxTvCertification = req.body.maxTvCertification;
+      user.settings.blockUnrated = req.body.blockUnrated;
+    }
   }

Apply the same guard when creating new UserSettings at lines 135–139.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/routes/user/usersettings.ts` around lines 135 - 152, When creating or
updating UserSettings, guard assignment of parental control fields
(parentalControlsEnabled, parentalControlsRegion, maxMovieCertification,
maxTvCertification, blockUnrated) with the same MANAGE_USERS permission check
used for quota fields: only set these properties on the new UserSettings object
or on user.settings when hasManageUsersPermission(req) (or the existing
permission helper used for quota) returns true; if the caller lacks
MANAGE_USERS, skip assigning those parental control fields so users cannot
modify their own parental controls.
src/components/BlocklistedTagsSelector/index.tsx (1)

116-184: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded key causes duplicate React keys across instances.

Line 167 hardcodes the key as keyword-select-blocklistedTags, but this component is now reused for multiple fields (excludedMovieTags, excludedTvTags, and blocklistedTags in SettingsMain). All three instances will have identical React keys, which can cause reconciliation issues.

Pass fieldName as a prop to ControlledKeywordSelector and use it in the key to ensure uniqueness.

Proposed fix

Update the BaseSelectorMultiProps type to include fieldName:

 type BaseSelectorMultiProps = {
   defaultValue?: string;
   value: MultiValue<SingleVal> | null;
   onChange: (value: MultiValue<SingleVal> | null) => void;
   components?: Partial<typeof components>;
+  fieldName: string;
 };

Pass fieldName from BlocklistedTagsSelector to ControlledKeywordSelector:

       <ControlledKeywordSelector
         value={selectorValue}
         onChange={update}
         defaultValue={defaultValue}
+        fieldName={fieldName}
         components={{

Use fieldName in the key:

     <AsyncSelect
-      key={`keyword-select-blocklistedTags`}
+      key={`keyword-select-${fieldName}`}
       inputId="data"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/BlocklistedTagsSelector/index.tsx` around lines 116 - 184,
ControlledKeywordSelector currently hardcodes
key="keyword-select-blocklistedTags" causing duplicate React keys when used for
excludedMovieTags, excludedTvTags and blocklistedTags; add a fieldName string to
BaseSelectorMultiProps, accept fieldName in ControlledKeywordSelector props,
pass fieldName from BlocklistedTagsSelector when rendering the controlled
selector, and use it to build a unique key (e.g. `keyword-select-${fieldName}`)
instead of the hardcoded value so each instance gets a distinct React key.
🧹 Nitpick comments (1)
src/components/Settings/SettingsMain/index.tsx (1)

441-494: 💤 Low value

Certification exclusions are hardcoded to US region only.

The onChange handlers (lines 464, 490) always set excludedCertificationRegion to 'US', and there is no UI control for users to select a different region. This means administrators can only exclude US certifications, not certifications from other regions.

This appears intentional (the component is named USCertificationSelector), but it's a design limitation to be aware of. If you plan to support other regions in the future, consider making the region configurable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Settings/SettingsMain/index.tsx` around lines 441 - 494, The
onChange handlers for USCertificationSelector (used for
excludedMovieCertifications and excludedTvCertifications) hardcode
setFieldValue('excludedCertificationRegion','US'); change this so the region is
not hardcoded: either read a region value from the selector callback (e.g.,
params.region) or add a region prop/UI control and pass that into the selector,
then call setFieldValue('excludedCertificationRegion', regionValue) instead;
ensure USCertificationSelector and its call sites (type="movie"/"tv",
certification prop) are updated to surface or accept the region so
administrators can select non-US regions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/lib/parentalControls.ts`:
- Around line 199-243: The three filter functions
(filterResultsByParentalControls, filterResultsByGlobalRatingExclusions,
filterResultsByGlobalTagExclusions) repeatedly call
TheMovieDb.getMovie/getTvShow for the same items, causing redundant TMDb API
calls; fix by consolidating into a single-pass filter (e.g.,
filterResultsByAllExclusions) or add a per-request details cache: iterate
results once using getResultMediaType, fetch details once via
TheMovieDb.getMovie/getTvShow, then call isMediaAllowedByParentalControls,
isMediaAllowedByGlobalRatingExclusions and isMediaAllowedByGlobalTagExclusions
against that single details object (or pass cached details into existing filter
helpers) and return filtered results; ensure early-return when no filters are
active and preserve type T in the result.
- Around line 218-240: Wrap each TMDb fetch inside the Promise.all map with
try/catch so a single failed fetch (from tmdb.getMovie or tmdb.getTvShow) does
not reject the whole Promise.all; inside the map used to produce allowedResults,
catch errors around the getResultMediaType branch and on error log/ignore the
item and return null so downstream isMediaAllowedByParentalControls is skipped
for failed fetches, preserving the existing return types and still filtering out
disallowed or failed items.

In `@server/lib/settings/index.ts`:
- Around line 427-428: The current defaults set excludedMovieCertifications and
excludedTvCertifications to 'NR', which hides all "Not Rated" content by
default; change these defaults to empty strings so no certifications are
excluded by default (update the values of excludedMovieCertifications and
excludedTvCertifications in settings/index.ts from 'NR' to ''), and adjust any
related docstrings, migration/defaults comments, or unit tests that assume 'NR'
is the default to reflect the new empty-string default.

In `@server/routes/discover.ts`:
- Around line 1307-1318: The response currently computes pagination using the
unfiltered watchlist size (watchlist.totalSize), which yields incorrect
totalResults/totalPages after applying filteredWatchlist; update the response to
derive totals from the filtered results instead: set totalResults to
filteredWatchlist.length and totalPages to Math.ceil(filteredWatchlist.length /
itemsPerPage) while keeping the results mapping (filteredWatchlist.map(...)) and
the page value as-is (page) so pagination reflects the filtered data.

In `@src/components/Settings/SettingsMain/index.tsx`:
- Around line 190-192: The UI currently falls back to the string 'NR' for
certification fields, which duplicates the old server default; update the
SettingsMain component to use empty-string fallbacks so they match the server
change: replace the expressions for excludedMovieCertifications and
excludedTvCertifications in src/components/Settings/SettingsMain/index.tsx (the
data?.excludedMovieCertifications and data?.excludedTvCertifications usages) to
use ?? '' instead of ?? 'NR' so the UI and server defaults stay consistent.

In `@src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx`:
- Around line 467-510: The two CertificationSelector instances (type="movie" and
type="tv") update parentalControlsRegion independently which can leave
maxMovieCertification or maxTvCertification inconsistent with the current
region; update the onChange handlers in both CertificationSelector usages so
when params.certificationCountry is present and different from
values.parentalControlsRegion you call setFieldValue('parentalControlsRegion',
params.certificationCountry ?? 'US') and also reset both certification fields
(setFieldValue('maxMovieCertification', undefined) and
setFieldValue('maxTvCertification', undefined) or to your form's cleared value)
before setting the new specific certification, ensuring parentalControlsRegion,
maxMovieCertification and maxTvCertification cannot become out-of-sync.

---

Outside diff comments:
In `@server/routes/user/usersettings.ts`:
- Around line 135-152: When creating or updating UserSettings, guard assignment
of parental control fields (parentalControlsEnabled, parentalControlsRegion,
maxMovieCertification, maxTvCertification, blockUnrated) with the same
MANAGE_USERS permission check used for quota fields: only set these properties
on the new UserSettings object or on user.settings when
hasManageUsersPermission(req) (or the existing permission helper used for quota)
returns true; if the caller lacks MANAGE_USERS, skip assigning those parental
control fields so users cannot modify their own parental controls.

In `@src/components/BlocklistedTagsSelector/index.tsx`:
- Around line 116-184: ControlledKeywordSelector currently hardcodes
key="keyword-select-blocklistedTags" causing duplicate React keys when used for
excludedMovieTags, excludedTvTags and blocklistedTags; add a fieldName string to
BaseSelectorMultiProps, accept fieldName in ControlledKeywordSelector props,
pass fieldName from BlocklistedTagsSelector when rendering the controlled
selector, and use it to build a unique key (e.g. `keyword-select-${fieldName}`)
instead of the hardcoded value so each instance gets a distinct React key.

---

Nitpick comments:
In `@src/components/Settings/SettingsMain/index.tsx`:
- Around line 441-494: The onChange handlers for USCertificationSelector (used
for excludedMovieCertifications and excludedTvCertifications) hardcode
setFieldValue('excludedCertificationRegion','US'); change this so the region is
not hardcoded: either read a region value from the selector callback (e.g.,
params.region) or add a region prop/UI control and pass that into the selector,
then call setFieldValue('excludedCertificationRegion', regionValue) instead;
ensure USCertificationSelector and its call sites (type="movie"/"tv",
certification prop) are updated to surface or accept the region so
administrators can select non-US regions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d45b2564-3e11-4500-9e2e-a5235be8477d

📥 Commits

Reviewing files that changed from the base of the PR and between 0a305f6 and 7966253.

📒 Files selected for processing (19)
  • next-env.d.ts
  • package.json
  • server/entity/MediaRequest.ts
  • server/entity/UserSettings.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/parentalControls.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1780776000000-AddParentalControlsToUserSettings.ts
  • server/migration/sqlite/1780776000000-AddParentalControlsToUserSettings.ts
  • server/routes/discover.ts
  • server/routes/movie.ts
  • server/routes/request.ts
  • server/routes/search.ts
  • server/routes/tv.ts
  • server/routes/user/usersettings.ts
  • src/components/BlocklistedTagsSelector/index.tsx
  • src/components/Settings/SettingsMain/index.tsx
  • src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
  • src/hooks/useDiscover.ts

Comment on lines +199 to +243
export const filterResultsByParentalControls = async <
T extends FilterableMediaResult,
>({
user,
results,
tmdb = new TheMovieDb(),
mediaType,
language,
}: {
user?: User;
results: T[];
tmdb?: TheMovieDb;
mediaType?: CertificationMediaType;
language?: string;
}): Promise<T[]> => {
if (!isParentalControlsEnabled(user)) {
return results;
}

const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);

if (!resultMediaType) {
return result;
}

const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });

return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);

return allowedResults.filter((result): result is T => !!result);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Performance concern: Redundant TMDb API calls across filter functions.

Each of the three filter functions (filterResultsByParentalControls, filterResultsByGlobalRatingExclusions, filterResultsByGlobalTagExclusions) independently fetches TMDb details for every result. When these filters are chained in routes (as shown in discover.ts), the same TMDb details are fetched up to three times per item.

For a typical page of 20 results, this could result in 60 TMDb API calls instead of 20.

Consider consolidating the filtering into a single pass that fetches details once and applies all three checks, or caching the TMDb responses within the request lifecycle.

♻️ Proposed approach: Unified filter function
export const filterResultsByAllExclusions = async <
  T extends FilterableMediaResult,
>({
  user,
  results,
  tmdb = new TheMovieDb(),
  mediaType,
  language,
}: {
  user?: User;
  results: T[];
  tmdb?: TheMovieDb;
  mediaType?: CertificationMediaType;
  language?: string;
}): Promise<T[]> => {
  const parentalEnabled = isParentalControlsEnabled(user);
  const hasGlobalRatingExclusions = 
    !!getSettings().main.excludedMovieCertifications ||
    !!getSettings().main.excludedTvCertifications;
  const hasGlobalTagExclusions = 
    getGlobalExcludedKeywordIds(MediaType.MOVIE).length > 0 ||
    getGlobalExcludedKeywordIds(MediaType.TV).length > 0;

  // Early return if no filtering needed
  if (!parentalEnabled && !hasGlobalRatingExclusions && !hasGlobalTagExclusions) {
    return results;
  }

  const allowedResults = await Promise.all(
    results.map(async (result) => {
      const resultMediaType = getResultMediaType(result, mediaType);
      if (!resultMediaType) return result;

      // Fetch details once
      const details =
        resultMediaType === MediaType.MOVIE
          ? await tmdb.getMovie({ movieId: result.id, language })
          : await tmdb.getTvShow({ tvId: result.id, language });

      // Apply all checks
      if (parentalEnabled) {
        const allowed = await isMediaAllowedByParentalControls({
          user, mediaType: resultMediaType, media: details, tmdb
        });
        if (!allowed) return null;
      }

      if (!isMediaAllowedByGlobalRatingExclusions({ mediaType: resultMediaType, media: details })) {
        return null;
      }

      if (!isMediaAllowedByGlobalTagExclusions({ mediaType: resultMediaType, media: details })) {
        return null;
      }

      return result;
    })
  );

  return allowedResults.filter((result): result is T => !!result);
};

Also applies to: 349-394, 481-524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/parentalControls.ts` around lines 199 - 243, The three filter
functions (filterResultsByParentalControls,
filterResultsByGlobalRatingExclusions, filterResultsByGlobalTagExclusions)
repeatedly call TheMovieDb.getMovie/getTvShow for the same items, causing
redundant TMDb API calls; fix by consolidating into a single-pass filter (e.g.,
filterResultsByAllExclusions) or add a per-request details cache: iterate
results once using getResultMediaType, fetch details once via
TheMovieDb.getMovie/getTvShow, then call isMediaAllowedByParentalControls,
isMediaAllowedByGlobalRatingExclusions and isMediaAllowedByGlobalTagExclusions
against that single details object (or pass cached details into existing filter
helpers) and return filtered results; ensure early-return when no filters are
active and preserve type T in the result.

Comment on lines +218 to +240
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);

if (!resultMediaType) {
return result;
}

const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });

return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing error handling for individual TMDb fetches.

Promise.all will reject entirely if any single TMDb fetch fails (e.g., a deleted movie returns 404). This could cause entire discovery pages to fail due to one problematic item.

Consider wrapping individual fetches with error handling to gracefully exclude failed items rather than failing the entire batch.

🛡️ Proposed fix with error handling
   const allowedResults: (T | null)[] = await Promise.all(
     results.map(async (result) => {
+      try {
         const resultMediaType = getResultMediaType(result, mediaType);

         if (!resultMediaType) {
           return result;
         }

         const details =
           resultMediaType === MediaType.MOVIE
             ? await tmdb.getMovie({ movieId: result.id, language })
             : await tmdb.getTvShow({ tvId: result.id, language });

         return (await isMediaAllowedByParentalControls({
           user,
           mediaType: resultMediaType,
           media: details,
           tmdb,
         }))
           ? (result as T)
           : null;
+      } catch {
+        // If we can't fetch details, exclude the item to be safe
+        return null;
+      }
     })
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);
if (!resultMediaType) {
return result;
}
const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });
return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
try {
const resultMediaType = getResultMediaType(result, mediaType);
if (!resultMediaType) {
return result;
}
const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });
return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
} catch {
// If we can't fetch details, exclude the item to be safe
return null;
}
})
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/parentalControls.ts` around lines 218 - 240, Wrap each TMDb fetch
inside the Promise.all map with try/catch so a single failed fetch (from
tmdb.getMovie or tmdb.getTvShow) does not reject the whole Promise.all; inside
the map used to produce allowedResults, catch errors around the
getResultMediaType branch and on error log/ignore the item and return null so
downstream isMediaAllowedByParentalControls is skipped for failed fetches,
preserving the existing return types and still filtering out disallowed or
failed items.

Comment on lines +427 to +428
excludedMovieCertifications: 'NR',
excludedTvCertifications: 'NR',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reconsider defaulting to exclude 'NR' content globally.

These defaults exclude all "Not Rated" content from discover/movie/series pages for every user in new installations. Many movies and TV shows lack ratings (international content, older content, independent films), so this could hide substantial content by default and surprise administrators.

Consider defaulting to empty strings (exclude nothing) and letting administrators explicitly opt-in to global exclusions.

Suggested default values
-        excludedMovieCertifications: 'NR',
-        excludedTvCertifications: 'NR',
+        excludedMovieCertifications: '',
+        excludedTvCertifications: '',
         excludedCertificationRegion: 'US',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
excludedMovieCertifications: 'NR',
excludedTvCertifications: 'NR',
excludedMovieCertifications: '',
excludedTvCertifications: '',
excludedCertificationRegion: 'US',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/settings/index.ts` around lines 427 - 428, The current defaults
set excludedMovieCertifications and excludedTvCertifications to 'NR', which
hides all "Not Rated" content by default; change these defaults to empty strings
so no certifications are excluded by default (update the values of
excludedMovieCertifications and excludedTvCertifications in settings/index.ts
from 'NR' to ''), and adjust any related docstrings, migration/defaults
comments, or unit tests that assume 'NR' is the default to reflect the new
empty-string default.

Comment thread server/routes/discover.ts
Comment on lines 1307 to 1318
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
results: filteredWatchlist.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
mediaType: item.mediaType,
tmdbId: item.tmdbId,
})),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Pagination metadata does not reflect filtered results.

After filtering the watchlist, totalPages and totalResults still use watchlist.totalSize (the original unfiltered count). This causes incorrect pagination: users may see "page 1 of 5" but only receive 3 results because 17 were filtered out.

Consider recalculating pagination based on filtered results, or documenting this as expected behavior.

🐛 Proposed fix
+    const filteredTotal = filteredWatchlist.length + offset; // Approximate, or track actual filtered total

     return res.json({
       page,
-      totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
-      totalResults: watchlist.totalSize,
+      totalPages: Math.ceil(filteredWatchlist.length / itemsPerPage) || 1,
+      totalResults: filteredWatchlist.length,
       results: filteredWatchlist.map((item) => ({

Note: A complete solution would require filtering the entire watchlist to get accurate totals, which has performance implications. Consider whether approximate pagination is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/routes/discover.ts` around lines 1307 - 1318, The response currently
computes pagination using the unfiltered watchlist size (watchlist.totalSize),
which yields incorrect totalResults/totalPages after applying filteredWatchlist;
update the response to derive totals from the filtered results instead: set
totalResults to filteredWatchlist.length and totalPages to
Math.ceil(filteredWatchlist.length / itemsPerPage) while keeping the results
mapping (filteredWatchlist.map(...)) and the page value as-is (page) so
pagination reflects the filtered data.

Comment on lines +190 to +192
excludedMovieCertifications:
data?.excludedMovieCertifications ?? 'NR',
excludedTvCertifications: data?.excludedTvCertifications ?? 'NR',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

UI defaults propagate the 'NR' exclusion.

The UI fallback values also default to 'NR' for certifications when server data is missing, which compounds the concern raised in server/lib/settings/index.ts (lines 427-428). If you change the server defaults to empty strings, update these UI fallbacks to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Settings/SettingsMain/index.tsx` around lines 190 - 192, The
UI currently falls back to the string 'NR' for certification fields, which
duplicates the old server default; update the SettingsMain component to use
empty-string fallbacks so they match the server change: replace the expressions
for excludedMovieCertifications and excludedTvCertifications in
src/components/Settings/SettingsMain/index.tsx (the
data?.excludedMovieCertifications and data?.excludedTvCertifications usages) to
use ?? '' instead of ?? 'NR' so the UI and server defaults stay consistent.

Comment on lines +467 to +510
<div className="form-row">
<label className="text-label">
{intl.formatMessage(messages.maxMovieCertification)}
</label>
<div className="form-input-area">
<CertificationSelector
type="movie"
certificationCountry={values.parentalControlsRegion}
certification={values.maxMovieCertification}
onChange={(params) => {
setFieldValue(
'parentalControlsRegion',
params.certificationCountry ?? 'US'
);
setFieldValue(
'maxMovieCertification',
params.certification
);
}}
/>
</div>
</div>
<div className="form-row">
<label className="text-label">
{intl.formatMessage(messages.maxTvCertification)}
</label>
<div className="form-input-area">
<CertificationSelector
type="tv"
certificationCountry={values.parentalControlsRegion}
certification={values.maxTvCertification}
onChange={(params) => {
setFieldValue(
'parentalControlsRegion',
params.certificationCountry ?? 'US'
);
setFieldValue(
'maxTvCertification',
params.certification
);
}}
/>
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent region/certification mismatch across movie and TV selectors.

Both CertificationSelector components share a single parentalControlsRegion field but update it independently. This can create inconsistent state:

  1. User selects US region with PG-13 for movies
  2. User selects GB region with 12A for TV
  3. Result: parentalControlsRegion='GB', maxMovieCertification='PG-13', maxTvCertification='12A'

Now maxMovieCertification holds a US certification (PG-13) while parentalControlsRegion is 'GB'. This mismatch may cause validation errors or unexpected filtering behavior in the enforcement layer.

Consider either:

  • Resetting both certification values when the region changes in either selector, or
  • Using a single shared region selector above both certification pickers to enforce a consistent region
🔧 Example fix: reset certifications on region change
 <CertificationSelector
   type="movie"
   certificationCountry={values.parentalControlsRegion}
   certification={values.maxMovieCertification}
   onChange={(params) => {
+    // If region changed, reset both certifications
+    if (params.certificationCountry !== values.parentalControlsRegion) {
+      setFieldValue('maxMovieCertification', undefined);
+      setFieldValue('maxTvCertification', undefined);
+    } else {
+      setFieldValue('maxMovieCertification', params.certification);
+    }
     setFieldValue(
       'parentalControlsRegion',
       params.certificationCountry ?? 'US'
     );
-    setFieldValue(
-      'maxMovieCertification',
-      params.certification
-    );
   }}
 />

Apply similar logic to the TV certification selector.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx` around
lines 467 - 510, The two CertificationSelector instances (type="movie" and
type="tv") update parentalControlsRegion independently which can leave
maxMovieCertification or maxTvCertification inconsistent with the current
region; update the onChange handlers in both CertificationSelector usages so
when params.certificationCountry is present and different from
values.parentalControlsRegion you call setFieldValue('parentalControlsRegion',
params.certificationCountry ?? 'US') and also reset both certification fields
(setFieldValue('maxMovieCertification', undefined) and
setFieldValue('maxTvCertification', undefined) or to your form's cleared value)
before setting the new specific certification, ensuring parentalControlsRegion,
maxMovieCertification and maxTvCertification cannot become out-of-sync.

Fixed issue with tag exlusion dropdown being behind other elements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant